<template>
    <view 
        v-if="show" 
        ref="lrlBubble" 
        :id="onlyId" 
        class="lrl-bubble" 
        :style="[positionStyle]" 
        @click="stopPropagation" 
        @touchstart="stopPropagation" 
        @touchmove="stopPropagation"
    >
        <view class="lrl-bubble__container" :style="[boxStyle]">
            <view 
                v-for="(item,index) in list" 
                :key="index" 
                class="lrl-bubble__container__cell"
                :class="{'lrl-bubble__container__cell--border': !!list[index + 1]}"
                :style="[cellStyle, { borderColor: lineColor }]"
                @tap="tapItem(item, index)"
            >
                <text v-if="item[mapKey.icon]"
                    :class="[
                        'lrl-bubble__container__cell__icon', 
                        customIconPrefix, 
                        item[mapKey.icon]
                    ]" 
                    :style="[
                        {
                            fontSize: sizeUnit(item.iconSize) || sizeUnit(iconSize) , 
                            color: item.iconColor || iconColor
                        }
                    ]"></text>
                <image 
                    v-else-if="item[mapKey.image]" 
                    :src="item[mapKey.image]"
                    class="lrl-bubble__container__cell__image"
                    :style="[
                        {
                            width: sizeUnit(item.iconSize) || sizeUnit(iconSize),
                            height: sizeUnit(item.iconSize) || sizeUnit(iconSize),
                            borderRadius: sizeUnit(imageBorderRadius)
                        }
                    ]"
                ></image>
                <text 
                    class="lrl-bubble__container__cell__text" 
                    :style="[
                        {
                            fontSize: sizeUnit(item.fontSize) || sizeUnit(fontSize),
                            color: item.fontColor || fontColor
                        }
                    ]"
                >{{ item[mapKey.text] }}</text>
            </view>
        </view>
    </view>
</template>

<script>
    import { deepClone, timeWait, getRect, getRandomLetter, isValue } from '../../libs/util/tools.js';
    import mixin from '../../libs/mixin/mixin.js'
    // #ifdef APP-NVUE
    const animation = uni.requireNativePlugin('animation')
    // #endif
    /**
     * BubbleMenu 自动定位弹出气泡菜单
     * @description [app|app-nvue|h5|mp-weixin] 自动上下左右定位弹出气泡菜单，一般用于点击或长按弹出跟随菜单
     * @author liruilin
     * @property {String}             position = [client|place] 定位模式，详见源码注释
     *  @value client 指定元素定位，在调用 open 时需要传入 clientId 和 rangeId，详见源码注释
     *  @value place  指定坐标位置，在调用 open 时需要传入 rangeId 和触摸事件的 pageX 和 pageY，详见源码注释
     * @property {String}             positionDotSize   指定x y位置时模拟点的大小，默认单位rpx，默认10
     * @property {String}             customIconPrefix  自定义字体图标库class，默认iconfont，nvue不支持
     * @property {String|Number}      fontSize          默认菜单文字大小，可在 list 单独配置 fontSize，默认单位rpx，默认32，
     * @property {String}             fontColor         默认菜单文字颜色，可在 list 单独配置 fontColor，默认#000
     * @property {String|Number}      iconSize          默认菜单图标大小，image图标时为高宽，可在 list 单独配置 iconSize，默认单位rpx，默认32
     * @property {String}             iconColor         默认菜单图标颜色，可在 list 单独配置 iconColor，默认#000
     * @property {String}             bgColor           背景颜色，默认#fff
     * @property {String|Number}      borderRadius      背景圆角，默认单位rpx，默认10
     * @property {String}             lineColor         选项底部线条颜色，默认#E0E0E0
     * @property {String|Number}      imageBorderRadius 菜单图片圆角，默认单位rpx，默认0
     * @property {String|Number}      minWidth          菜单最小宽度，默认单位rpx，默认100
     * @property {String|Number}      maxWidth          菜单最大宽度，默认单位rpx，默认空，最好由组件自动计算最大宽度为准。
     * @property {String|Number}      zIndex            菜单层级，默认10，nvue不支持，nvue将标签放置在最底部即为最高层级
     * @property {Object}             map               {text, icon, image} 数据key映射，text为显示文字映射，icon为图标icon class映射，image为图片图标映射
     * @property {Boolean}            clickClose = [true|false]        点击菜单是否关闭
     * @property {Boolean}            moveTransition = [true|false]    菜单未关闭状态下再次打开定位时是否启用动画过渡移动效果
     * @property {Boolean}            repeatOpenTransitionHide = [true|false]  在开启移动动画时，重新定位打开是否过渡一次隐藏，一般在选项有变化时设置为true，主要是解决选项切换导致的渲染闪烁问题。
     * @example <lrl-bubble-menu ref="lrlBubbleMenu"></lrl-bubble-menu>
     */
    export default {
        name: 'lrl-bubble-menu',
        mixins: [
            mixin
        ],
        props: {
            position: {
                type: String,
                default: 'client'
            },
            positionDotSize: {
                type: [String, Number],
                default: 10
            },
            customIconPrefix: {
                type: String,
                default: 'iconfont'
            },
            fontSize: {
                type: [String, Number],
                default: '32'
            },
            fontColor: {
                type: String,
                default: '#000'
            },
            iconSize: {
                type: [String, Number],
                default: 32
            },
            iconColor: {
                type: String,
                default: '#000'
            },
            bgColor: {
                type: String,
                default: '#FFFFFF'
            },
            borderRadius: {
                type: [String, Number],
                default: 10
            },
            lineColor: {
                type: String,
                default: '#E0E0E0'
            },
            imageBorderRadius: {
                type: [String, Number],
                default: 0
            },
            minWidth: {
                type: [String, Number],
                default: 100
            },
            maxWidth: {
                type: [String, Number],
                default: ''
            },
            zIndex: {
                type: [String, Number],
                default: 10
            },
            clickClose: {
                type: Boolean,
                default: true
            },
            moveTransition: {
                type: Boolean,
                default: false
            },
            repeatOpenTransitionHide: {
                type: Boolean,
                default: false
            },
            map: {
                type: Object,
                default: () => {
                    return {
                        text: 'text',
                        icon: 'icon',
                        image: 'image'
                    }
                }
            },
        },
        data() {
            return {
				// 因为是使用的dom查询，避免一个页面多次使用组件时保证唯一id
				onlyId: getRandomLetter(8),
                show: false,
                showHorn: false,
                opacity: false,
                clientId: '',
                list: [],
                range: {},
                client: {},
                bubble: {},
                top: '',
                left: '',
                calMaxWidth: '', 
                pageX: 0,
                pageY: 0,
				animationStyles: {}
            }
        },
		created() {
			// function change
			this.changeEvent = null
		},
        computed: {
            /**
             * 合并映射key
             */
            mapKey(){
                return Object.assign({
                    text: 'text',
                    icon: 'icon',
                    image: 'image'
                }, this.map)
            },
            positionStyle() {
                const {
                    zIndex,
					animationStyles
                } = this

                let style = {
                    // #ifndef APP-NVUE
                    zIndex,
                    // #endif
					...animationStyles
                }
                
                return style
            },
            boxStyle() {
                const {
                    zIndex,
                    bgColor,
                    opacity,
                    borderRadius
                } = this
                let style = {
                    backgroundColor: bgColor,
                    borderRadius: this.sizeUnit(borderRadius),
                    zIndex
                }
                return style
            },
            cellStyle(){
                const [seUpMaxW, { calMaxWidth }] = [this.sizeRpx2px(this.maxWidth), this]
                return {
                    minWidth: this.sizeUnit(this.minWidth),
                    // 最大宽度由计算得出，组件设置的最大宽度超过能显示的最大宽度将不会生效，这里16 * 2是算的为了兼容nvue做的padding
                    maxWidth: ( (seUpMaxW && seUpMaxW < calMaxWidth ? seUpMaxW : calMaxWidth) - uni.upx2px(16 * 2) ) + 'px'
                }
            }
        },
        methods: {
            reset(){
                this.left = ''
                this.top = ''
            },
            async open({
                // 被定位元素id
                clientId,
                // 定位可视范围元素id
                rangeId,
                // 被定位元素所属组件，一般不需要传入，在被查找的id不在当前使用组件时需要传入，app以及h5不传可以查找到子组件里面的id
                clientInRef = this.$parent,
                // 定位可视范围元素所属组件，一般不需要传入，在被查找的id不在当前使用组件时需要传入，app以及h5不传可以查找到子组件里面的id
                rangeInRef = this.$parent,
                /**
                 * 菜单选项
                 * [
                 *    {text, icon: 图标, image: 图片图标, fontSize: 文字大小, iconSize: 图标大小，为image图标宽度, color: 文字颜色, iconColor: 图标颜色}
                 * ]
                 * 优先展示icon，无icon将以image为图标
                 */                 
                list = [],
                /**
                 * pageX、clientX等的区别
                 * https://blog.csdn.net/qq_45467083/article/details/106366800
                 */
                // 跟随手指定位模式的x轴
                pageX = 0,
                // 跟随手指定位模式的y轴
                pageY = 0,
                // 定位左右偏量阈值，可自定义往左或者往右的偏移量
                xAxisScale = 1,
                // 优先展示位置 [top | bottom]
                priorityPosition = 'top',
                // 可视区域左边安全距离 rpx
                windowLeft = 0,
                // 可视区域右边安全距离 rpx
                windowRight = 0,
                // 可视区域顶部安全距离 rpx
                windowTop = 0,
                // 可视区域底部安全距离 rpx
                windowBottom = 0,
				// 自定义事件，点击菜单时触发，第一个参数为被点击菜单数据，第二个为被点击菜单索引，可用作判断点击类型
				onChange = () => {}
            } = {}) {
				
				const oldLeft = this.left
				const oldTop = this.top
				
                // 是否是未关闭再次重新定位打开
                let oldShow = this.show
                // 重复打开执行关闭
                if(oldShow && clientId === this.clientId && this.position == 'client'){
                    this.close()
                    return 
                }
				
                // 未关闭菜单的情况再次打开，处理计算前的隐藏，只会有一种情况不需要隐藏：开启移动动画并且未开启移动隐藏
                if(oldShow && !(this.moveTransition && !this.repeatOpenTransitionHide) ){

					// 先按上一个事件的位置隐藏，如果oldShow为true，必定会有oldLeft 和 oldTop
					await this.bubbleAnimation({
						opacity: 0,
						'transform': `translate(${ oldLeft }px, ${ oldTop }px)`
						// 这里设置时间的目的是优化移动隐藏时间，太久了感官不太好
					}, this.moveTransition && this.repeatOpenTransitionHide ? 100 : 60, 0)
					
                }
				
                // 选择性设计，浅拷贝在使用的时候可以动态修改属性
                this.list = list
                // this.list = deepClone(list)

                this.clientId = clientId
                this.show   = true
                this.range  = {}
                this.client = {}
                this.bubble = {}
                this.windowLeft   = uni.upx2px(windowLeft)
                this.windowRight  = uni.upx2px(windowRight)
                this.windowTop    = uni.upx2px(windowTop)
                this.windowBottom = uni.upx2px(windowBottom)
                this.xAxisScale   = xAxisScale
                this.pageX        = pageX
                this.pageY        = pageY
				this.changeEvent  = onChange

                this.$nextTick(async function() {
                    // 可视区域信息
                    this.range = await getRect('#' + rangeId, rangeInRef)
                    // 定位基准元素信息
                    if(this.position == 'client'){
                        // 跟随指定元素，获取元素高度宽度 left right
                        this.client = await getRect('#' + clientId, clientInRef)
                    }else {
                        // 指定位置模拟出一个位置根据点，
                        let positionDotSize = this.sizeRpx2px(this.positionDotSize)
                        this.client = {
                            width: positionDotSize,
                            height: positionDotSize,
                            left: pageX - (positionDotSize / 2),
                            right: pageX + (positionDotSize / 2),
                            top: pageY - (positionDotSize / 2),
                            bottom: pageY + (positionDotSize / 2)
                        }
                    }
                    // 计算最大宽度
                    // this.calMaxWidth = this.range.width - (this.windowLeft + this.windowRight) - (uni.getSystemInfoSync().windowWidth - this.range.right)
                    this.calMaxWidth = this.range.width - (this.windowLeft + this.windowRight) 
                    // 这里需要等待气泡菜单最终宽度生效。
                    this.$nextTick(async () => {
                        // #ifdef APP-NVUE
                        await timeWait(10)
                        // #endif
                        // 获取气泡菜单信息
                        this.bubble = await getRect('#' + this.onlyId, this)
                        // console.log(JSON.stringify(this.range))
                        // console.log(JSON.stringify(this.client))
                        // console.log(JSON.stringify(this.bubble))
                        if(priorityPosition === 'top'){
                            this.topCompute()
                        }else {
                            this.bottomCompute()
                        }
                        this.leftCompute()
                        
                        this.$nextTick(async () => {
							const { top, left} = this
	
	
                            // 开启移动动画处理
                            if(!( this.moveTransition && oldShow )){
								// 上一个事件未关闭的情况下再次打开菜单
								if(oldShow){
									// 移动到当前事件定位位置
									await this.bubbleAnimation({
										 opacity: 0,
										'transform': `translate(${ left }px, ${ top }px)`
									}, 0, 0)
								}else {
									// 
									await this.bubbleAnimation({
										'transform': `translate(${ left }px, ${ top }px)`
									}, 0, 0)
								}
                            }
							this.bubbleAnimation({
							    opacity: 1,
								'transform': `translate(${ left }px, ${ top }px)`
							}, 100, 0)

                        })
                        
                    })
                })

            },
            // 气泡菜单动画
            bubbleAnimation(styles, duration = 200, delay = 0){
                
                return new Promise(async resolve => {
					// #ifdef APP-NVUE
					// nvue使用weex动画
                    animation.transition(this.$refs['lrlBubble'], {
                        styles,
                        duration, //ms
                        timingFunction: 'ease-in',
                        delay //ms
                    },()=>{
                        resolve()
                    })
					// #endif
					
					// #ifndef APP-NVUE
					styles['transition-duration'] = isValue(duration) ? `${ duration / 1000 }s` : '0.3s'
					this.animationStyles = {...this.animationStyles, ...styles}
					await timeWait(duration)
					resolve()
					// #endif
                })
            },
            // 以顶部优先定位计算
            topCompute(already = false){
                const { range, client, bubble ,windowTop} = this
                // 顶部可显示区域高度
                let contentTopHeight = client.top - range.top
                // 如果可显示区域高度 + 设置顶部安全高度 大于气泡菜单高度，那么就直接以顶部展示
                if(contentTopHeight + windowTop > bubble.height){
                    this.top = client.top - bubble.height + windowTop
                    return true
                }else if(!already){
                    // 否则计算以底部定位
                    if(!this.bottomCompute(true)){
                        // 如果以底部定位计算也未能显示就已 client 可视区域的top为气泡菜单的top展示即可
                        this.top = range.top + windowTop
                        return true
                    }
                }
                return false
            },
            // 以底部优先定位计算
            bottomCompute(already = false){
                const { range, client, bubble, windowBottom, windowTop } = this
                // 
                if ((range.height + range.top) - (client.height + client.top) + windowBottom >= bubble.height) {
                    this.top = client.top + client.height
                    return true
                } else if(!already){
                    if(!this.topCompute(true)){
                        // 如果以高度计算未能显示就已可视区域最底部对齐，计算方式为 可视区域高度 + 可视区域top - 气泡菜单的高度 - 底部安全区
                        this.top = range.top + range.height - bubble.height - windowBottom
                        return true
                    }
                }
                return false
            },
            // 计算左右位置，
            leftCompute(){
                const { range, client, bubble, windowLeft, windowRight , xAxisScale} = this
                // 先计算在无任何限制的情况下应有的left值
                let initLeft = client.left + (client.width / 2) - (bubble.width / 2) 
                // x轴偏移比例计算
                initLeft = initLeft * xAxisScale
                // 能设置最大的left值，可视范围left + 可视范围width - 气泡菜单的宽度，即为最大left
                let maxLeft = range.left + range.width - bubble.width - windowRight
                // 能设置的最小left值，可视范围left + 设置的安全left值
                let minLeft = range.left + windowLeft
                // 如最初计算left小于可视范围left说明left过于靠左，直接设置最小left
                if(initLeft < minLeft){
                    this.left = minLeft
                }else {
                    // 最初计算出气泡菜单left如果大于最大left值
                    this.left = initLeft > maxLeft ? maxLeft : initLeft
                }
            },
            // 隐藏菜单
            async close() {
                if(this.show){
                    let styles = {
                        opacity: 0,
						'transform': `translate(${ this.left }px, ${ this.top }px)`
                    }
                    this.bubbleAnimation(styles, 200, 0).then(() => {
                        this.show = false
                    })
                }
            },
            // 点击选项
            tapItem(item, index) {
                if (this.clickClose) this.close()
                // this.$emit('change', Object.assign({}, item), index)
				if(this.changeEvent && typeof this.changeEvent === 'function'){
					this.changeEvent(Object.assign({}, item), index)
				}
				
            },
            // 取消冒泡
            stopPropagation(e) {
            	// #ifndef APP-NVUE
            	e.stopPropagation && typeof e.stopPropagation === 'function' && e.stopPropagation()
            	// #endif
            },
        },
        
    }
</script>

<style lang="scss" scoped>
    
$lrl-bubble-padding-left: 16rpx;
$lrl-bubble-padding-right: 16rpx;
$lrl-bubble-padding-top: 16rpx;
$lrl-bubble-padding-bottom: 16rpx;
$lrl-bubble-padding-bottom: 16rpx;
$lrl-bubble-container-box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.15);
$lrl-bubble-container-border: 1px solid rgba(0, 0, 0, 0);
$lrl-bubble-container-cell-padding-left: 20rpx;
$lrl-bubble-container-cell-padding-right: 20rpx;
$lrl-bubble-container-cell-padding-top: 15rpx;
$lrl-bubble-container-cell-padding-bottom: 15rpx;
$lrl-bubble-container-cell-border-bottom-width: 1px;
$lrl-bubble-container-cell-icon-margin-right: 10rpx;


.lrl-bubble {
    position: fixed;
    padding-left: $lrl-bubble-padding-left;
    padding-right: $lrl-bubble-padding-right;
    padding-top: $lrl-bubble-padding-top;
    padding-bottom: $lrl-bubble-padding-bottom;
	top: 0;
	left: 0;
	transition-property: transform, opacity;
    /* #ifdef APP-NVUE */
    opacity: 0;
    /* #endif */
    &__container {
        position: relative;
        display: flex;
        flex-direction: column;
        justify-content: flex-start;
        box-shadow: $lrl-bubble-container-box-shadow;
        /* #ifdef APP-NVUE */
        // DOTO 2022/6/24 nvue不知是什么情况不设置边框阴影显示不出来  // 兼容 nvue
        border: $lrl-bubble-container-border;
        /* #endif */
        /* #ifndef APP-NVUE */
        width: max-content;
        /* #endif */
        
        &__cell {
            padding-left: $lrl-bubble-container-cell-padding-left;
            padding-right: $lrl-bubble-container-cell-padding-right;
            padding-top: $lrl-bubble-container-cell-padding-top;
            padding-bottom: $lrl-bubble-container-cell-padding-bottom;
            display: flex;
            flex-direction: row;
            align-items: center;
            
            &--border{
                border-bottom-width: $lrl-bubble-container-cell-border-bottom-width;
                border-bottom-style: solid;
            }
            
            &__text {
                flex: 1;
                /* #ifndef APP-NVUE */
                display: inline-block;
                /* #endif */
                word-break: break-all;
                white-space: pre-wrap;
            }
            
            &__icon{
                margin-right: $lrl-bubble-container-cell-icon-margin-right;
                flex-shrink: 0;
            }
            
            &__image{
                margin-right: $lrl-bubble-container-cell-icon-margin-right;
                flex-shrink: 0;
            }
        }
    }

}
</style>
